#
# openlookup/engine/item.py
#
# Copyright 2008-2009 Helsinki Institute for Information Technology
# and the authors.
#
# Authors: Ken Rimey <rimey@hiit.fi>
#

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import time
from hashlib import sha1
from binascii import hexlify, unhexlify

from openlookup.lib.sexp import encode, decode
from openlookup.lib.cipher import Cipher
from openlookup.engine.identity import Identity, decode_identity
from openlookup.engine.signature import Signature, decode_signature

MAX_TTL = 7 * 24 * 3600         # one week, as in OpenDHT
MAX_VALUE_SIZE = 8192           # was 1024 in OpenDHT
MAX_VALUE_PADDING = 32          # allowable encryption overhead
MAX_SECRET_SIZE = 40

class InvalidItem(ValueError): pass
class ValueTooLarge(InvalidItem): pass

class Item(object):
    def __init__(self, expiration, key,
                 value=None, secret=None, pk=None, sig=None):
        self.expiration = expiration # integer
        self.key = key               # 40, 80, or 120 hex digits
        self.value = value           # binary string or None
        self.secret = secret         # binary string or None
        self.pk = pk                 # Identity instance or None
        self.sig = sig               # Signature instance or None

    def __hash__(self):
        return hash(self._digest())

    def __cmp__(self, other):
        return cmp(self._digest(), other._digest())

    def _digest(self):
        return (self.type, self.key, self.expiration,
                not self.value, # sort tombstones after non-tombstones
                self.value, self.secret)

    def validate(self):
        """
        Raise ValueError if the instance is not valid.
        """
        if not isinstance(self.expiration, int):
            raise InvalidItem('Expected integer for expiration.')

        if not is_canonical_hex(self.key):
            raise InvalidItem('Expected hexadecimal string for internal key.')

        if self.value is not None:
            if not isinstance(self.value, str):
                raise InvalidItem('Expected string for value.')
            if len(self.value) > MAX_VALUE_SIZE + MAX_VALUE_PADDING:
                raise ValueTooLarge('Value too large.')

        if self.secret is not None:
            if not isinstance(self.secret, str):
                raise InvalidItem('Expected string for secret.')
            if len(self.secret) > MAX_SECRET_SIZE:
                raise InvalidItem('Secret too large.')

        if self.pk is not None and not isinstance(self.pk, Identity):
            raise InvalidItem('Expected Identity instance.')

        if self.sig is not None and not isinstance(self.sig, Signature):
            raise InvalidItem('Expected Signature instance.')

    def to_dict(self):
        """
        Return a Python dictionary encoding the instance data.

        This dictionary is suitable for use as a set of keyword
        parameters for recreate_item().
        """
        data = {}
        data['type'] = self.type
        data['key'] = self.key
        data['expiration'] = self.expiration

        if self.value is not None:
            data['value'] = self.value

        if self.secret is not None:
            data['secret'] = self.secret

        if self.pk is not None:
            data['pk'] = self.pk.export_public() # list of strings

        if self.sig is not None:
            data['sig'] = self.sig.export()      # list of strings

        return data

    def export(self, omit_sig=False):
        """
        Return a list structure encoding the instance data.
        """
        data = self.to_dict()
        type = data.pop('type')
        data['expiration'] = str(data['expiration'])
        if omit_sig and 'sig' in data:
            del data['sig']

        pairs = data.items()
        pairs.sort()
        return [type] + pairs

    def encode(self, omit_sig=False):
        """
        Return a string encoding the instance data as a canonical s-expression.
        """
        return encode(self.export(omit_sig))

    def get_ttl(self):
        """
        Return the item's remaining time-to-live in seconds.
        """
        return self.expiration - int(time.time())

class MultivaluedItem(Item):
    type = 'multivalued'

    def __init__(self, expiration, key, value=None, secret=None):
        Item.__init__(self, expiration, key, value, secret=secret)

    @property
    def value_hash(self):
        return self.key[40:80]  # 40 hex digits

    @property
    def secret_hash(self):
        return self.key[80:]    # 40 hex digits or empty

    def validate(self):
        Item.validate(self)

        if len(self.key) not in (80, 120):
            raise InvalidItem('Illegal length for internal key.')

        if self.value is None and self.secret is None:
            raise InvalidItem('Value or secret is required.')
        elif self.value is not None and self.secret is not None:
            raise InvalidItem('Only one of value or secret is allowed.')

        if self.value is not None:
            if sha1(self.value).hexdigest() != self.value_hash:
                raise InvalidItem('Incorrect value hash.')

        if self.secret is not None:
            if sha1(self.secret).hexdigest() != self.secret_hash:
                raise InvalidItem('Incorrect secret hash.')

class SingletonItem(Item):
    type = 'singleton'

    def __init__(self, expiration, key, value=None, pk=None, sig=None):
        Item.__init__(self, expiration, key, value, pk=pk, sig=sig)

    @property
    def principal(self):
        # Complementing the bits of the public key hash is a hack to
        # avoid collisions with value hashes.
        return hex_complement(self.key[40:80]) or None

    def validate(self):
        Item.validate(self)

        if len(self.key) == 40:
            if self.pk is not None:
                raise InvalidItem('Found public key in anonymous item.')
            if self.sig is not None:
                raise InvalidItem('Found signature in anonymous item.')
        elif len(self.key) == 80:
            if self.pk is None:
                raise InvalidItem('Public key missing.')
            if self.sig is None:
                raise InvalidItem('Signature missing.')
        else:
            raise InvalidItem('Illegal length for internal key.')

        if self.pk is not None:
            if self.pk.hexdigest() != self.principal:
                raise InvalidItem('Incorrect public key hash.')

        if self.sig is not None:
            # Note that the verification can also raise ValueError.
            if not self.pk.verify(self.encode(omit_sig=True), self.sig):
                raise InvalidItem('Unable to verify signature.')

    def sign(self, identity):
        assert len(self.key) == 40
        self.key += hex_complement(identity.hexdigest())
        self.pk = decode_identity(identity.export_public())
        self.sig = identity.sign(self.encode(omit_sig=True))

    def decrypt_value(self, label):
        assert sha1(label).hexdigest() == self.key[:40]
        if self.value is None:
            return None
        else:
            return Cipher(label).decrypt(self.value)

def decode_item(sexp):
    """
    Decodes a canonical s-expression or list structure to recreate an item.

    Can raise either ValueError or TypeError.
    """
    sexp = sexp if isinstance(sexp, list) else decode(sexp)
    type, data = sexp[0], sexp[1:]
    data = dict(data)
    expiration = int(data.pop('expiration'))
    return recreate_item(type=type, expiration=expiration, **data)

def recreate_item(expiration, key, type=None, **kwargs):
    kwargs = dict((k, v) for (k, v) in kwargs.iteritems() if v is not None)

    if 'pk' in kwargs:
        kwargs['pk'] = decode_identity(kwargs['pk'])

    if 'sig' in kwargs:
        kwargs['sig'] = decode_signature(kwargs['sig'])

    if type is None:
        if len(key) > 40:
            type = 'multivalued'
        else:
            type = 'singleton' 

    if type == 'multivalued':
        return MultivaluedItem(expiration, key, **kwargs)
    elif type == 'singleton':
        return SingletonItem(expiration, key, **kwargs)
    else:
        raise ValueError('Unknown item type: %s' % type)

def create_singleton_item(label, value=None, identity=None, ttl=None):
    if value is not None:
        check_value_size(value)
        value = Cipher(label).encrypt(value)

    expiration = get_expiration_time(ttl)
    label_hash = sha1(label).hexdigest()
    item = SingletonItem(expiration, label_hash, value)

    if identity is not None:
        item.sign(identity)

    return item

def create_multivalued_item(key_hash, value_hash, secret_hash,
                            value=None, secret=None, ttl=None):
    if value is not None:
        check_value_size(value)

    expiration = get_expiration_time(ttl)
    combined_key = key_hash + value_hash + secret_hash
    return MultivaluedItem(expiration, combined_key, value, secret)

def get_expiration_time(ttl=None):
    if ttl is None:
        ttl = MAX_TTL
    else:
        ttl = max(1, min(MAX_TTL, int(ttl)))

    now = int(time.time())
    return now + ttl

def check_value_size(value):
    if len(value) > MAX_VALUE_SIZE:
        raise ValueTooLarge('Value exceeds %d bytes (got %d).'
                            % (MAX_VALUE_SIZE, len(value)))

def is_canonical_hex(s):
    try:
        return (isinstance(s, str)
                and hexlify(unhexlify(s)) == s)
    except TypeError:
        return False

def hex_complement(s):
    return ''.join('%x' % (15 - int(c, 16)) for c in s)
